@@ -0,0 +1,453 @@ |
||
| 1 |
+require 'delegate' |
|
| 2 |
+require 'net/imap' |
|
| 3 |
+require 'mail' |
|
| 4 |
+ |
|
| 5 |
+module Agents |
|
| 6 |
+ class ImapFolderAgent < Agent |
|
| 7 |
+ cannot_receive_events! |
|
| 8 |
+ |
|
| 9 |
+ default_schedule "every_30m" |
|
| 10 |
+ |
|
| 11 |
+ description <<-MD |
|
| 12 |
+ |
|
| 13 |
+ The ImapFolderAgent checks an IMAP server in specified folders |
|
| 14 |
+ and creates Events based on new unread mails. |
|
| 15 |
+ |
|
| 16 |
+ Specify an IMAP server to connect with `host`, and set `ssl` to |
|
| 17 |
+ true if the server supports IMAP over SSL. Specify `port` if |
|
| 18 |
+ you need to connect to a port other than standard (143 or 993 |
|
| 19 |
+ depending on the `ssl` value). |
|
| 20 |
+ |
|
| 21 |
+ Specify login credentials in `username` and `password`. |
|
| 22 |
+ |
|
| 23 |
+ List the names of folders to check in `folders`. |
|
| 24 |
+ |
|
| 25 |
+ To narrow mails by conditions, build a `conditions` hash with |
|
| 26 |
+ the following keys: |
|
| 27 |
+ |
|
| 28 |
+ - "subject" |
|
| 29 |
+ - "body" |
|
| 30 |
+ |
|
| 31 |
+ Specify a regular expression to match against the decoded |
|
| 32 |
+ subject/body of each mail. |
|
| 33 |
+ |
|
| 34 |
+ Use the `(?i)` directive for case-insensitive search. For |
|
| 35 |
+ example, a pattern `(?i)alert` will match "alert", "Alert" |
|
| 36 |
+ or "ALERT". You can also make only a part of a pattern to |
|
| 37 |
+ work case-insensitively: `Re: (?i:alert)` will match either |
|
| 38 |
+ "Re: Alert" or "Re: alert", but not "RE: alert". |
|
| 39 |
+ |
|
| 40 |
+ When a mail has multiple non-attachment text parts, they are |
|
| 41 |
+ prioritized according to the `mime_types` option (which see |
|
| 42 |
+ below) and the first part that matches a "body" pattern, if |
|
| 43 |
+ specified, will be chosen as the "body" value in a created |
|
| 44 |
+ event. |
|
| 45 |
+ |
|
| 46 |
+ Named captues will appear in the "matches" hash in a created |
|
| 47 |
+ event. |
|
| 48 |
+ |
|
| 49 |
+ - "from", "to", "cc" |
|
| 50 |
+ |
|
| 51 |
+ Specify a shell glob pattern string that is matched against |
|
| 52 |
+ mail addresses extracted from the corresponding header |
|
| 53 |
+ values of each mail. |
|
| 54 |
+ |
|
| 55 |
+ Patterns match addresses in case insensitive manner. |
|
| 56 |
+ |
|
| 57 |
+ Multiple pattern strings can be specified in an array, in |
|
| 58 |
+ which case a mail is selected if any of the patterns |
|
| 59 |
+ matches. (i.e. patterns are OR'd) |
|
| 60 |
+ |
|
| 61 |
+ - "mime_types" |
|
| 62 |
+ |
|
| 63 |
+ Specify an array of MIME types to tell which non-attachment |
|
| 64 |
+ part of a mail among its text/* parts should be used as mail |
|
| 65 |
+ body. The default value is `['text/plain', 'text/enriched', |
|
| 66 |
+ 'text/html']`. |
|
| 67 |
+ |
|
| 68 |
+ - "has_attachment" |
|
| 69 |
+ |
|
| 70 |
+ Setting this to true or false means only mails that does or does |
|
| 71 |
+ not have an attachment are selected. |
|
| 72 |
+ |
|
| 73 |
+ If this key is unspecified or set to null, it is ignored. |
|
| 74 |
+ |
|
| 75 |
+ Set `mark_as_read` to true to mark found mails as read. |
|
| 76 |
+ |
|
| 77 |
+ Each agent instance memorizes a list of unread mails that are |
|
| 78 |
+ found in the last run, so even if you change a set of conditions |
|
| 79 |
+ so that it matches mails that are missed previously, they will |
|
| 80 |
+ not show up as new events. Also, in order to avoid duplicated |
|
| 81 |
+ notification it keeps a list of Message-Id's of 100 most recent |
|
| 82 |
+ mails, so if multiple mails of the same Message-Id are found, |
|
| 83 |
+ you will only see one event out of them. |
|
| 84 |
+ MD |
|
| 85 |
+ |
|
| 86 |
+ event_description <<-MD |
|
| 87 |
+ Events look like this: |
|
| 88 |
+ |
|
| 89 |
+ {
|
|
| 90 |
+ "folder": "INBOX", |
|
| 91 |
+ "subject": "...", |
|
| 92 |
+ "from": "Nanashi <nanashi.gombeh@example.jp>", |
|
| 93 |
+ "to": ["Jane <jane.doe@example.com>"], |
|
| 94 |
+ "cc": [], |
|
| 95 |
+ "date": "2014-05-10T03:47:20+0900", |
|
| 96 |
+ "mime_type": "text/plain", |
|
| 97 |
+ "body": "Hello,\n\n...", |
|
| 98 |
+ "matches": {
|
|
| 99 |
+ } |
|
| 100 |
+ } |
|
| 101 |
+ MD |
|
| 102 |
+ |
|
| 103 |
+ IDCACHE_SIZE = 100 |
|
| 104 |
+ |
|
| 105 |
+ FNM_FLAGS = [:FNM_CASEFOLD, :FNM_EXTGLOB].inject(0) { |flags, sym|
|
|
| 106 |
+ if File.const_defined?(sym) |
|
| 107 |
+ flags | File.const_get(sym) |
|
| 108 |
+ else |
|
| 109 |
+ flags |
|
| 110 |
+ end |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 113 |
+ def working? |
|
| 114 |
+ event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs? |
|
| 115 |
+ end |
|
| 116 |
+ |
|
| 117 |
+ def default_options |
|
| 118 |
+ {
|
|
| 119 |
+ 'expected_update_period_in_days' => "1", |
|
| 120 |
+ 'host' => 'imap.gmail.com', |
|
| 121 |
+ 'ssl' => true, |
|
| 122 |
+ 'username' => 'your.account', |
|
| 123 |
+ 'password' => 'your.password', |
|
| 124 |
+ 'folders' => %w[INBOX], |
|
| 125 |
+ 'conditions' => {}
|
|
| 126 |
+ } |
|
| 127 |
+ end |
|
| 128 |
+ |
|
| 129 |
+ def validate_options |
|
| 130 |
+ %w[host username password].each { |key|
|
|
| 131 |
+ String === options[key] or |
|
| 132 |
+ errors.add(:base, '%s is required and must be a string' % key) |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 135 |
+ if options['port'].present? |
|
| 136 |
+ errors.add(:base, "port must be a positive integer") unless is_positive_integer?(options['port']) |
|
| 137 |
+ end |
|
| 138 |
+ |
|
| 139 |
+ %w[ssl mark_as_read].each { |key|
|
|
| 140 |
+ if options[key].present? |
|
| 141 |
+ case options[key] |
|
| 142 |
+ when true, false |
|
| 143 |
+ else |
|
| 144 |
+ errors.add(:base, '%s must be a boolean value' % key) |
|
| 145 |
+ end |
|
| 146 |
+ end |
|
| 147 |
+ } |
|
| 148 |
+ |
|
| 149 |
+ case mime_types = options['mime_types'] |
|
| 150 |
+ when nil |
|
| 151 |
+ when Array |
|
| 152 |
+ mime_types.all? { |mime_type|
|
|
| 153 |
+ String === mime_type && mime_type.start_with?('text/')
|
|
| 154 |
+ } or errors.add(:base, 'mime_types may only contain strings that match "text/*".') |
|
| 155 |
+ if mime_types.empty? |
|
| 156 |
+ errors.add(:base, 'mime_types should not be empty') |
|
| 157 |
+ end |
|
| 158 |
+ else |
|
| 159 |
+ errors.add(:base, 'mime_types must be an array') |
|
| 160 |
+ end |
|
| 161 |
+ |
|
| 162 |
+ case folders = options['folders'] |
|
| 163 |
+ when nil |
|
| 164 |
+ when Array |
|
| 165 |
+ folders.all? { |folder|
|
|
| 166 |
+ String === folder |
|
| 167 |
+ } or errors.add(:base, 'folders may only contain strings') |
|
| 168 |
+ if folders.empty? |
|
| 169 |
+ errors.add(:base, 'folders should not be empty') |
|
| 170 |
+ end |
|
| 171 |
+ else |
|
| 172 |
+ errors.add(:base, 'folders must be an array') |
|
| 173 |
+ end |
|
| 174 |
+ |
|
| 175 |
+ case conditions = options['conditions'] |
|
| 176 |
+ when nil |
|
| 177 |
+ when Hash |
|
| 178 |
+ conditions.each { |key, value|
|
|
| 179 |
+ value.present? or next |
|
| 180 |
+ case key |
|
| 181 |
+ when 'subject', 'body' |
|
| 182 |
+ case value |
|
| 183 |
+ when String |
|
| 184 |
+ begin |
|
| 185 |
+ Regexp.new(value) |
|
| 186 |
+ rescue |
|
| 187 |
+ errors.add(:base, 'conditions.%s contains an invalid regexp' % key) |
|
| 188 |
+ end |
|
| 189 |
+ else |
|
| 190 |
+ errors.add(:base, 'conditions.%s contains a non-string object' % key) |
|
| 191 |
+ end |
|
| 192 |
+ when 'from', 'to', 'cc' |
|
| 193 |
+ Array(value).each { |pattern|
|
|
| 194 |
+ case pattern |
|
| 195 |
+ when String |
|
| 196 |
+ begin |
|
| 197 |
+ glob_match?(pattern, '') |
|
| 198 |
+ rescue |
|
| 199 |
+ errors.add(:base, 'conditions.%s contains an invalid glob pattern' % key) |
|
| 200 |
+ end |
|
| 201 |
+ else |
|
| 202 |
+ errors.add(:base, 'conditions.%s contains a non-string object' % key) |
|
| 203 |
+ end |
|
| 204 |
+ } |
|
| 205 |
+ when 'has_attachment' |
|
| 206 |
+ case value |
|
| 207 |
+ when true, false |
|
| 208 |
+ else |
|
| 209 |
+ errors.add(:base, 'conditions.%s must be a boolean value or null' % key) |
|
| 210 |
+ end |
|
| 211 |
+ end |
|
| 212 |
+ } |
|
| 213 |
+ else |
|
| 214 |
+ errors.add(:base, 'conditions must be a hash') |
|
| 215 |
+ end |
|
| 216 |
+ |
|
| 217 |
+ if options['expected_update_period_in_days'].present? |
|
| 218 |
+ errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days']) |
|
| 219 |
+ end |
|
| 220 |
+ end |
|
| 221 |
+ |
|
| 222 |
+ def check |
|
| 223 |
+ # 'seen' keeps a hash of { uidvalidity => uids, ... } which
|
|
| 224 |
+ # lists unread mails in watched folders. |
|
| 225 |
+ seen = memory['seen'] || {}
|
|
| 226 |
+ new_seen = Hash.new { |hash, key|
|
|
| 227 |
+ hash[key] = [] |
|
| 228 |
+ } |
|
| 229 |
+ |
|
| 230 |
+ # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
|
|
| 231 |
+ # most recent notified mails. |
|
| 232 |
+ notified = memory['notified'] || [] |
|
| 233 |
+ |
|
| 234 |
+ each_unread_mail { |mail|
|
|
| 235 |
+ new_seen[mail.uidvalidity] << mail.uid |
|
| 236 |
+ |
|
| 237 |
+ next if (uids = seen[mail.uidvalidity]) && uids.include?(mail.uid) |
|
| 238 |
+ |
|
| 239 |
+ body_parts = mail.body_parts(mime_types) |
|
| 240 |
+ matched_part = nil |
|
| 241 |
+ matches = {}
|
|
| 242 |
+ |
|
| 243 |
+ options['conditions'].all? { |key, value|
|
|
| 244 |
+ case key |
|
| 245 |
+ when 'subject' |
|
| 246 |
+ value.present? or next true |
|
| 247 |
+ re = Regexp.new(value) |
|
| 248 |
+ if m = re.match(mail.subject) |
|
| 249 |
+ m.names.each { |name|
|
|
| 250 |
+ matches[name] = m[name] |
|
| 251 |
+ } |
|
| 252 |
+ true |
|
| 253 |
+ else |
|
| 254 |
+ false |
|
| 255 |
+ end |
|
| 256 |
+ when 'body' |
|
| 257 |
+ value.present? or next true |
|
| 258 |
+ re = Regexp.new(value) |
|
| 259 |
+ matched_part = body_parts.find { |part|
|
|
| 260 |
+ if m = re.match(part.decoded) |
|
| 261 |
+ m.names.each { |name|
|
|
| 262 |
+ matches[name] = m[name] |
|
| 263 |
+ } |
|
| 264 |
+ true |
|
| 265 |
+ else |
|
| 266 |
+ false |
|
| 267 |
+ end |
|
| 268 |
+ } |
|
| 269 |
+ when 'from', 'to', 'cc' |
|
| 270 |
+ value.present? or next true |
|
| 271 |
+ mail.header[key].addresses.any? { |address|
|
|
| 272 |
+ Array(value).any? { |pattern|
|
|
| 273 |
+ glob_match?(pattern, address) |
|
| 274 |
+ } |
|
| 275 |
+ } |
|
| 276 |
+ when 'has_attachment' |
|
| 277 |
+ value == mail.has_attachment? |
|
| 278 |
+ else |
|
| 279 |
+ log 'Unknown condition key ignored: %s' % key |
|
| 280 |
+ true |
|
| 281 |
+ end |
|
| 282 |
+ } or next |
|
| 283 |
+ |
|
| 284 |
+ unless notified.include?(mail.message_id) |
|
| 285 |
+ matched_part ||= body_parts.first |
|
| 286 |
+ |
|
| 287 |
+ if matched_part |
|
| 288 |
+ mime_type = matched_part.mime_type |
|
| 289 |
+ body = matched_part.decoded |
|
| 290 |
+ else |
|
| 291 |
+ mime_type = 'text/plain' |
|
| 292 |
+ body = '' |
|
| 293 |
+ end |
|
| 294 |
+ |
|
| 295 |
+ create_event :payload => {
|
|
| 296 |
+ 'folder' => mail.folder, |
|
| 297 |
+ 'subject' => mail.subject, |
|
| 298 |
+ 'from' => mail.from_addrs.first, |
|
| 299 |
+ 'to' => mail.to_addrs, |
|
| 300 |
+ 'cc' => mail.cc_addrs, |
|
| 301 |
+ 'date' => (mail.date.iso8601 rescue nil), |
|
| 302 |
+ 'mime_type' => mime_type, |
|
| 303 |
+ 'body' => body, |
|
| 304 |
+ 'matches' => matches, |
|
| 305 |
+ 'has_attachment' => mail.has_attachment?, |
|
| 306 |
+ } |
|
| 307 |
+ |
|
| 308 |
+ notified << mail.message_id if mail.message_id |
|
| 309 |
+ end |
|
| 310 |
+ |
|
| 311 |
+ if options['mark_as_read'] |
|
| 312 |
+ log 'Marking as read' |
|
| 313 |
+ mail.mark_as_read |
|
| 314 |
+ end |
|
| 315 |
+ } |
|
| 316 |
+ |
|
| 317 |
+ notified.slice!(0...-IDCACHE_SIZE) if notified.size > IDCACHE_SIZE |
|
| 318 |
+ |
|
| 319 |
+ memory['seen'] = new_seen |
|
| 320 |
+ memory['notified'] = notified |
|
| 321 |
+ save! |
|
| 322 |
+ end |
|
| 323 |
+ |
|
| 324 |
+ def each_unread_mail |
|
| 325 |
+ host, port, ssl, username = options.values_at(:host, :port, :ssl, :username) |
|
| 326 |
+ |
|
| 327 |
+ log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
|
|
| 328 |
+ Client.open(host, Integer(port), ssl) { |imap|
|
|
| 329 |
+ log "Logging in as #{username}"
|
|
| 330 |
+ imap.login(username, options[:password]) |
|
| 331 |
+ |
|
| 332 |
+ options['folders'].each { |folder|
|
|
| 333 |
+ log "Selecting the folder: %s" % folder |
|
| 334 |
+ |
|
| 335 |
+ imap.select(folder) |
|
| 336 |
+ |
|
| 337 |
+ unseen = imap.search('UNSEEN')
|
|
| 338 |
+ |
|
| 339 |
+ if unseen.empty? |
|
| 340 |
+ log "No unread mails" |
|
| 341 |
+ next |
|
| 342 |
+ end |
|
| 343 |
+ |
|
| 344 |
+ imap.fetch_mails(unseen).each { |mail|
|
|
| 345 |
+ yield mail |
|
| 346 |
+ } |
|
| 347 |
+ } |
|
| 348 |
+ } |
|
| 349 |
+ ensure |
|
| 350 |
+ log 'Connection closed' |
|
| 351 |
+ end |
|
| 352 |
+ |
|
| 353 |
+ def mime_types |
|
| 354 |
+ options['mime_types'] || %w[text/plain text/enriched text/html] |
|
| 355 |
+ end |
|
| 356 |
+ |
|
| 357 |
+ private |
|
| 358 |
+ |
|
| 359 |
+ def is_positive_integer?(value) |
|
| 360 |
+ Integer(value) >= 0 |
|
| 361 |
+ rescue |
|
| 362 |
+ false |
|
| 363 |
+ end |
|
| 364 |
+ |
|
| 365 |
+ def glob_match?(pattern, value) |
|
| 366 |
+ File.fnmatch?(pattern, value, FNM_FLAGS) |
|
| 367 |
+ end |
|
| 368 |
+ |
|
| 369 |
+ class Client < ::Net::IMAP |
|
| 370 |
+ class << self |
|
| 371 |
+ def open(host, port, ssl) |
|
| 372 |
+ imap = new(host, port, ssl) |
|
| 373 |
+ yield imap |
|
| 374 |
+ ensure |
|
| 375 |
+ imap.disconnect |
|
| 376 |
+ end |
|
| 377 |
+ end |
|
| 378 |
+ |
|
| 379 |
+ def select(folder) |
|
| 380 |
+ ret = super(@folder = folder) |
|
| 381 |
+ @uidvalidity = responses['UIDVALIDITY'].last |
|
| 382 |
+ ret |
|
| 383 |
+ end |
|
| 384 |
+ |
|
| 385 |
+ def fetch_mails(set) |
|
| 386 |
+ fetch(set, %w[UID RFC822.HEADER]).map { |data|
|
|
| 387 |
+ Message.new(self, data, folder: @folder, uidvalidity: @uidvalidity) |
|
| 388 |
+ } |
|
| 389 |
+ end |
|
| 390 |
+ end |
|
| 391 |
+ |
|
| 392 |
+ class Message < SimpleDelegator |
|
| 393 |
+ DEFAULT_BODY_MIME_TYPES = %w[text/plain text/enriched text/html] |
|
| 394 |
+ |
|
| 395 |
+ attr_reader :uid, :folder, :uidvalidity |
|
| 396 |
+ |
|
| 397 |
+ def initialize(client, fetch_data, props = {})
|
|
| 398 |
+ @client = client |
|
| 399 |
+ props.each { |key, value|
|
|
| 400 |
+ instance_variable_set(:"@#{key}", value)
|
|
| 401 |
+ } |
|
| 402 |
+ attr = fetch_data.attr |
|
| 403 |
+ @uid = attr['UID'] |
|
| 404 |
+ super(Mail.read_from_string(attr['RFC822.HEADER'])) |
|
| 405 |
+ end |
|
| 406 |
+ |
|
| 407 |
+ def has_attachment? |
|
| 408 |
+ @has_attachment ||= |
|
| 409 |
+ begin |
|
| 410 |
+ data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first |
|
| 411 |
+ struct_has_attachment?(data.attr['BODYSTRUCTURE']) |
|
| 412 |
+ end |
|
| 413 |
+ end |
|
| 414 |
+ |
|
| 415 |
+ def fetch |
|
| 416 |
+ @parsed ||= |
|
| 417 |
+ begin |
|
| 418 |
+ data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first |
|
| 419 |
+ Mail.read_from_string(data.attr['BODY[]']) |
|
| 420 |
+ end |
|
| 421 |
+ end |
|
| 422 |
+ |
|
| 423 |
+ def body_parts(mime_types = DEFAULT_BODY_MIME_TYPES) |
|
| 424 |
+ mail = fetch |
|
| 425 |
+ if mail.multipart? |
|
| 426 |
+ mail.body.set_sort_order(mime_types) |
|
| 427 |
+ mail.body.sort_parts! |
|
| 428 |
+ mail.all_parts |
|
| 429 |
+ else |
|
| 430 |
+ [mail] |
|
| 431 |
+ end.reject { |part|
|
|
| 432 |
+ part.multipart? || part.attachment? || !part.text? || |
|
| 433 |
+ !mime_types.include?(part.mime_type) |
|
| 434 |
+ } |
|
| 435 |
+ end |
|
| 436 |
+ |
|
| 437 |
+ def mark_as_read |
|
| 438 |
+ @client.uid_store(@uid, '+FLAGS', [:Seen]) |
|
| 439 |
+ end |
|
| 440 |
+ |
|
| 441 |
+ private |
|
| 442 |
+ |
|
| 443 |
+ def struct_has_attachment?(struct) |
|
| 444 |
+ struct.multipart? && ( |
|
| 445 |
+ struct.subtype == 'MIXED' || |
|
| 446 |
+ struct.parts.any? { |part|
|
|
| 447 |
+ struct_has_attachment?(part) |
|
| 448 |
+ } |
|
| 449 |
+ ) |
|
| 450 |
+ end |
|
| 451 |
+ end |
|
| 452 |
+ end |
|
| 453 |
+end |
@@ -0,0 +1,22 @@ |
||
| 1 |
+From: Nanashi <nanashi.gombeh@example.jp> |
|
| 2 |
+Date: Fri, 9 May 2014 16:00:00 +0900 |
|
| 3 |
+Message-ID: <foo.123@mail.example.jp> |
|
| 4 |
+Subject: some subject |
|
| 5 |
+To: Jane <jane.doe@example.com>, John <john.doe@example.com> |
|
| 6 |
+MIME-Version: 1.0 |
|
| 7 |
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b |
|
| 8 |
+ |
|
| 9 |
+--d8c92622e09101e4bc833685557b |
|
| 10 |
+Content-Type: text/plain; charset=UTF-8 |
|
| 11 |
+ |
|
| 12 |
+Some plain text |
|
| 13 |
+Some second line |
|
| 14 |
+ |
|
| 15 |
+--d8c92622e09101e4bc833685557b |
|
| 16 |
+Content-Type: text/html; charset=UTF-8 |
|
| 17 |
+Content-Transfer-Encoding: quoted-printable |
|
| 18 |
+ |
|
| 19 |
+<div dir=3D"ltr">Some HTML document<br> |
|
| 20 |
+Some second line of HTML<br></div> |
|
| 21 |
+ |
|
| 22 |
+--d8c92622e09101e4bc833685557b-- |
@@ -0,0 +1,20 @@ |
||
| 1 |
+From: John <john.doe@example.com> |
|
| 2 |
+Date: Fri, 9 May 2014 17:00:00 +0900 |
|
| 3 |
+Message-ID: <bar.456@mail.example.com> |
|
| 4 |
+Subject: Re: some subject |
|
| 5 |
+To: Jane <jane.doe@example.com>, Nanashi <nanashi.gombeh@example.jp> |
|
| 6 |
+MIME-Version: 1.0 |
|
| 7 |
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b |
|
| 8 |
+ |
|
| 9 |
+--d8c92622e09101e4bc833685557b |
|
| 10 |
+Content-Type: text/plain; charset=UTF-8 |
|
| 11 |
+ |
|
| 12 |
+Some reply |
|
| 13 |
+ |
|
| 14 |
+--d8c92622e09101e4bc833685557b |
|
| 15 |
+Content-Type: text/html; charset=UTF-8 |
|
| 16 |
+Content-Transfer-Encoding: quoted-printable |
|
| 17 |
+ |
|
| 18 |
+<div dir=3D"ltr">Some HTML reply<br></div> |
|
| 19 |
+ |
|
| 20 |
+--d8c92622e09101e4bc833685557b-- |
@@ -0,0 +1,242 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+require 'time' |
|
| 3 |
+ |
|
| 4 |
+describe Agents::ImapFolderAgent do |
|
| 5 |
+ describe 'checking IMAP' do |
|
| 6 |
+ before do |
|
| 7 |
+ @site = {
|
|
| 8 |
+ 'expected_update_period_in_days' => 1, |
|
| 9 |
+ 'host' => 'mail.example.net', |
|
| 10 |
+ 'ssl' => true, |
|
| 11 |
+ 'username' => 'foo', |
|
| 12 |
+ 'password' => 'bar', |
|
| 13 |
+ 'folders' => ['INBOX'], |
|
| 14 |
+ 'conditions' => {
|
|
| 15 |
+ } |
|
| 16 |
+ } |
|
| 17 |
+ @checker = Agents::ImapFolderAgent.new(:name => 'Example', :options => @site, :keep_events_for => 2) |
|
| 18 |
+ @checker.user = users(:bob) |
|
| 19 |
+ @checker.save! |
|
| 20 |
+ |
|
| 21 |
+ message_mixin = Module.new {
|
|
| 22 |
+ def folder |
|
| 23 |
+ 'INBOX' |
|
| 24 |
+ end |
|
| 25 |
+ |
|
| 26 |
+ def uidvalidity |
|
| 27 |
+ '100' |
|
| 28 |
+ end |
|
| 29 |
+ |
|
| 30 |
+ def has_attachment? |
|
| 31 |
+ false |
|
| 32 |
+ end |
|
| 33 |
+ |
|
| 34 |
+ def body_parts(mime_types = %[text/plain text/enriched text/html]) |
|
| 35 |
+ mime_types.map { |type|
|
|
| 36 |
+ all_parts.find { |part|
|
|
| 37 |
+ part.mime_type == type |
|
| 38 |
+ } |
|
| 39 |
+ }.compact |
|
| 40 |
+ end |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ @mails = [ |
|
| 44 |
+ Mail.read(Rails.root.join('spec/data_fixtures/imap1.eml')).tap { |mail|
|
|
| 45 |
+ mail.extend(message_mixin) |
|
| 46 |
+ stub(mail).uid.returns(1) |
|
| 47 |
+ }, |
|
| 48 |
+ Mail.read(Rails.root.join('spec/data_fixtures/imap2.eml')).tap { |mail|
|
|
| 49 |
+ mail.extend(message_mixin) |
|
| 50 |
+ stub(mail).uid.returns(2) |
|
| 51 |
+ stub(mail).has_attachment?.returns(true) |
|
| 52 |
+ }, |
|
| 53 |
+ ] |
|
| 54 |
+ |
|
| 55 |
+ stub(@checker).each_unread_mail.returns { |yielder|
|
|
| 56 |
+ @mails.each(&yielder) |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ @payloads = [ |
|
| 60 |
+ {
|
|
| 61 |
+ 'folder' => 'INBOX', |
|
| 62 |
+ 'from' => 'nanashi.gombeh@example.jp', |
|
| 63 |
+ 'to' => ['jane.doe@example.com', 'john.doe@example.com'], |
|
| 64 |
+ 'cc' => [], |
|
| 65 |
+ 'date' => '2014-05-09T16:00:00+09:00', |
|
| 66 |
+ 'subject' => 'some subject', |
|
| 67 |
+ 'body' => "Some plain text\nSome second line\n", |
|
| 68 |
+ 'has_attachment' => false, |
|
| 69 |
+ 'matches' => {},
|
|
| 70 |
+ 'mime_type' => 'text/plain', |
|
| 71 |
+ }, |
|
| 72 |
+ {
|
|
| 73 |
+ 'folder' => 'INBOX', |
|
| 74 |
+ 'from' => 'john.doe@example.com', |
|
| 75 |
+ 'to' => ['jane.doe@example.com', 'nanashi.gombeh@example.jp'], |
|
| 76 |
+ 'cc' => [], |
|
| 77 |
+ 'subject' => 'Re: some subject', |
|
| 78 |
+ 'body' => "Some reply\n", |
|
| 79 |
+ 'date' => '2014-05-09T17:00:00+09:00', |
|
| 80 |
+ 'has_attachment' => true, |
|
| 81 |
+ 'matches' => {},
|
|
| 82 |
+ 'mime_type' => 'text/plain', |
|
| 83 |
+ } |
|
| 84 |
+ ] |
|
| 85 |
+ end |
|
| 86 |
+ |
|
| 87 |
+ describe 'validations' do |
|
| 88 |
+ before do |
|
| 89 |
+ @checker.should be_valid |
|
| 90 |
+ end |
|
| 91 |
+ |
|
| 92 |
+ it 'should validate the integer fields' do |
|
| 93 |
+ @checker.options['expected_update_period_in_days'] = 'nonsense' |
|
| 94 |
+ @checker.should_not be_valid |
|
| 95 |
+ |
|
| 96 |
+ @checker.options['expected_update_period_in_days'] = '2' |
|
| 97 |
+ @checker.should be_valid |
|
| 98 |
+ |
|
| 99 |
+ @checker.options['port'] = -1 |
|
| 100 |
+ @checker.should_not be_valid |
|
| 101 |
+ |
|
| 102 |
+ @checker.options['port'] = 'imap' |
|
| 103 |
+ @checker.should_not be_valid |
|
| 104 |
+ |
|
| 105 |
+ @checker.options['port'] = '143' |
|
| 106 |
+ @checker.should be_valid |
|
| 107 |
+ |
|
| 108 |
+ @checker.options['port'] = 993 |
|
| 109 |
+ @checker.should be_valid |
|
| 110 |
+ end |
|
| 111 |
+ |
|
| 112 |
+ it 'should validate the boolean fields' do |
|
| 113 |
+ @checker.options['ssl'] = false |
|
| 114 |
+ @checker.should be_valid |
|
| 115 |
+ |
|
| 116 |
+ @checker.options['ssl'] = 'true' |
|
| 117 |
+ @checker.should_not be_valid |
|
| 118 |
+ end |
|
| 119 |
+ |
|
| 120 |
+ it 'should validate regexp conditions' do |
|
| 121 |
+ @checker.options['conditions'] = {
|
|
| 122 |
+ 'subject' => '(foo' |
|
| 123 |
+ } |
|
| 124 |
+ @checker.should_not be_valid |
|
| 125 |
+ |
|
| 126 |
+ @checker.options['conditions'] = {
|
|
| 127 |
+ 'body' => '***' |
|
| 128 |
+ } |
|
| 129 |
+ @checker.should_not be_valid |
|
| 130 |
+ |
|
| 131 |
+ @checker.options['conditions'] = {
|
|
| 132 |
+ 'subject' => '\ARe:', |
|
| 133 |
+ 'body' => '(?<foo>http://\S+)' |
|
| 134 |
+ } |
|
| 135 |
+ @checker.should be_valid |
|
| 136 |
+ end |
|
| 137 |
+ end |
|
| 138 |
+ |
|
| 139 |
+ describe '#check' do |
|
| 140 |
+ it 'should check for mails and save memory' do |
|
| 141 |
+ lambda { @checker.check }.should change { Event.count }.by(2)
|
|
| 142 |
+ @checker.memory['notified'].sort.should == @mails.map(&:message_id).sort |
|
| 143 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
|
|
| 144 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 147 |
+ Event.last(2).map(&:payload) == @payloads |
|
| 148 |
+ |
|
| 149 |
+ lambda { @checker.check }.should_not change { Event.count }
|
|
| 150 |
+ end |
|
| 151 |
+ |
|
| 152 |
+ it 'should narrow mails by To' do |
|
| 153 |
+ @checker.options['conditions']['to'] = 'John.Doe@*' |
|
| 154 |
+ |
|
| 155 |
+ lambda { @checker.check }.should change { Event.count }.by(1)
|
|
| 156 |
+ @checker.memory['notified'].sort.should == [@mails.first.message_id] |
|
| 157 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
|
|
| 158 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
| 159 |
+ } |
|
| 160 |
+ |
|
| 161 |
+ Event.last.payload.should == @payloads.first |
|
| 162 |
+ |
|
| 163 |
+ lambda { @checker.check }.should_not change { Event.count }
|
|
| 164 |
+ end |
|
| 165 |
+ |
|
| 166 |
+ it 'should perform regexp matching and save named captures' do |
|
| 167 |
+ @checker.options['conditions'].update( |
|
| 168 |
+ 'subject' => '\ARe: (?<a>.+)', |
|
| 169 |
+ 'body' => 'Some (?<b>.+) reply', |
|
| 170 |
+ ) |
|
| 171 |
+ |
|
| 172 |
+ lambda { @checker.check }.should change { Event.count }.by(1)
|
|
| 173 |
+ @checker.memory['notified'].sort.should == [@mails.last.message_id] |
|
| 174 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
|
|
| 175 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
| 176 |
+ } |
|
| 177 |
+ |
|
| 178 |
+ Event.last.payload.should == @payloads.last.update( |
|
| 179 |
+ 'body' => "<div dir=\"ltr\">Some HTML reply<br></div>\n", |
|
| 180 |
+ 'matches' => { 'a' => 'some subject', 'b' => 'HTML' },
|
|
| 181 |
+ 'mime_type' => 'text/html', |
|
| 182 |
+ ) |
|
| 183 |
+ |
|
| 184 |
+ lambda { @checker.check }.should_not change { Event.count }
|
|
| 185 |
+ end |
|
| 186 |
+ |
|
| 187 |
+ it 'should narrow mails by has_attachment (true)' do |
|
| 188 |
+ @checker.options['conditions']['has_attachment'] = true |
|
| 189 |
+ |
|
| 190 |
+ lambda { @checker.check }.should change { Event.count }.by(1)
|
|
| 191 |
+ |
|
| 192 |
+ Event.last.payload['subject'].should == 'Re: some subject' |
|
| 193 |
+ end |
|
| 194 |
+ |
|
| 195 |
+ it 'should narrow mails by has_attachment (false)' do |
|
| 196 |
+ @checker.options['conditions']['has_attachment'] = false |
|
| 197 |
+ |
|
| 198 |
+ lambda { @checker.check }.should change { Event.count }.by(1)
|
|
| 199 |
+ |
|
| 200 |
+ Event.last.payload['subject'].should == 'some subject' |
|
| 201 |
+ end |
|
| 202 |
+ |
|
| 203 |
+ it 'should narrow mail parts by MIME types' do |
|
| 204 |
+ @checker.options['mime_types'] = %w[text/plain] |
|
| 205 |
+ @checker.options['conditions'].update( |
|
| 206 |
+ 'subject' => '\ARe: (?<a>.+)', |
|
| 207 |
+ 'body' => 'Some (?<b>.+) reply', |
|
| 208 |
+ ) |
|
| 209 |
+ |
|
| 210 |
+ lambda { @checker.check }.should_not change { Event.count }
|
|
| 211 |
+ @checker.memory['notified'].sort.should == [] |
|
| 212 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
|
|
| 213 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
| 214 |
+ } |
|
| 215 |
+ end |
|
| 216 |
+ |
|
| 217 |
+ it 'should never mark mails as read unless mark_as_read is true' do |
|
| 218 |
+ @mails.each { |mail|
|
|
| 219 |
+ stub(mail).mark_as_read.never |
|
| 220 |
+ } |
|
| 221 |
+ lambda { @checker.check }.should change { Event.count }.by(2)
|
|
| 222 |
+ end |
|
| 223 |
+ |
|
| 224 |
+ it 'should mark mails as read if mark_as_read is true' do |
|
| 225 |
+ @checker.options['mark_as_read'] = true |
|
| 226 |
+ @mails.each { |mail|
|
|
| 227 |
+ stub(mail).mark_as_read.once |
|
| 228 |
+ } |
|
| 229 |
+ lambda { @checker.check }.should change { Event.count }.by(2)
|
|
| 230 |
+ end |
|
| 231 |
+ |
|
| 232 |
+ it 'should create just one event for multiple mails with the same Message-Id' do |
|
| 233 |
+ @mails.first.message_id = @mails.last.message_id |
|
| 234 |
+ @checker.options['mark_as_read'] = true |
|
| 235 |
+ @mails.each { |mail|
|
|
| 236 |
+ stub(mail).mark_as_read.once |
|
| 237 |
+ } |
|
| 238 |
+ lambda { @checker.check }.should change { Event.count }.by(1)
|
|
| 239 |
+ end |
|
| 240 |
+ end |
|
| 241 |
+ end |
|
| 242 |
+end |